import atexit
import contextlib
from io import StringIO, BytesIO
import os
import re
import shutil
import subprocess
import sys
import tempfile
import time
import traceback
from unittest import TestCase
from itertools import permutations

from voc.python.blocks import Block as PyBlock
from voc.python.modules import Module as PyModule
from voc.java.constants import ConstantPool, Utf8
from voc.java.klass import ClassFileReader, ClassFileWriter

from voc.java.attributes import Code as JavaCode
from voc.transpiler import Transpiler

# get path to `tests` directory
TESTS_DIR = os.path.dirname(__file__)

# A state variable to determine if the test environment has been configured.
_suite_configured = False

# Temporary directory containing all files generated by this test process
# Set during setUpSuite()
_output_dir = ''


def setUpSuite():
    """Configure the entire test suite.

    This only needs to be run once, prior to the first test.

    Part of the setup is to compile the support library;
    this step can be skipped by exporting PRECOMPILE=false
    into the test environment.
    """
    global _output_dir
    global _suite_configured

    if _suite_configured:
        return

    def remove_output_dir():
        global _output_dir
        if _output_dir != '':
            try:
                shutil.rmtree(_output_dir)
            except FileNotFoundError:
                pass

    atexit.register(remove_output_dir)
    _output_dir = tempfile.mkdtemp(dir=TESTS_DIR)

    os.environ['VOC_BUILD_DIR'] = os.path.join(_output_dir, 'build')
    os.environ['VOC_DIST_DIR'] = os.path.join(_output_dir, 'dist')

    # If the code has been precompiled, we don't have to
    # compile it as part of the test suite setup.
    precompile = os.environ.get('PRECOMPILE', 'true').lower() == 'true'
    if not precompile:
        _suite_configured = True
        return

    proc = subprocess.Popen(
        "ant java",
        stdin=subprocess.PIPE,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        shell=True,
    )

    try:
        out, err = proc.communicate(timeout=30)
    except subprocess.TimeoutExpired:
        proc.kill()
        out, err = proc.communicate()
        raise

    if proc.returncode != 0:
        raise Exception("Error compiling java sources: " + out.decode('ascii'))

    _suite_configured = True


@contextlib.contextmanager
def capture_output(redirect_stderr=True):
    oldout, olderr = sys.stdout, sys.stderr
    try:
        out = StringIO()
        sys.stdout = out
        if redirect_stderr:
            sys.stderr = out
        else:
            sys.stderr = StringIO()
        yield out
    except:
        if redirect_stderr:
            traceback.print_exc()
        else:
            raise
    finally:
        sys.stdout, sys.stderr = oldout, olderr


def adjust(text, run_in_function=False):
    """Adjust a code sample to remove leading whitespace."""
    lines = text.split('\n')
    if len(lines) == 1:
        return text

    if lines[0].strip() == '':
        lines = lines[1:]
    first_line = lines[0].lstrip()
    n_spaces = len(lines[0]) - len(first_line)

    final_lines = [('    ' if run_in_function else '') + line[n_spaces:] for line in lines]

    if run_in_function:
        final_lines = [
            "def test_function():",
        ] + final_lines + [
            "test_function()",
        ]

    return '\n'.join(final_lines)


def runAsPython(test_dir, main_code, extra_code=None, args=None):
    """Run a block of Python code with the Python interpreter."""
    # Output source code into test directory
    with open(os.path.join(test_dir, 'test.py'), 'w', encoding='utf-8') as py_source:
        py_source.write(main_code)

    if extra_code:
        for name, code in extra_code.items():
            path = name.split('.')
            path[-1] = path[-1] + '.py'
            if len(path) != 1:
                try:
                    os.makedirs(os.path.join(test_dir, *path[:-1]))
                except FileExistsError:
                    pass
            with open(os.path.join(test_dir, *path), 'w', encoding="utf-8") as py_source:
                py_source.write(adjust(code))

    if args is None:
        args = []

    proc = subprocess.Popen(
        [sys.executable, "test.py"] + args,
        stdin=subprocess.PIPE,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        cwd=test_dir,
    )
    out = proc.communicate()

    return out[0].decode('utf8')


def compileJava(java_dir, java):
    if not java:
        return None

    sources = []
    with capture_output():
        for descriptor, code in java.items():
            parts = descriptor.split('/')

            class_dir = os.path.sep.join(parts[:-1])
            class_file = os.path.join(class_dir, "%s.java" % parts[-1])

            full_dir = os.path.join(java_dir, class_dir)
            full_path = os.path.join(java_dir, class_file)

            try:
                os.makedirs(full_dir)
            except FileExistsError:
                pass

            with open(full_path, 'w', encoding="utf-8") as java_source:
                java_source.write(adjust(code))

            sources.append(class_file)

    classpath = os.pathsep.join([
        os.path.abspath(os.path.join('dist', 'python-java-support.jar')),
        os.path.abspath(java_dir),
    ])
    proc = subprocess.Popen(
        ["javac", "-classpath", classpath] + sources,
        stdin=subprocess.PIPE,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        cwd=java_dir,
    )
    out = proc.communicate()

    return out[0].decode('utf8')


JAVA_EXCEPTION = re.compile(
    '(((Exception in thread "[\w-]+" )?(?P<exception1>[\w]+): (?P<message1>[^\r?\n]+))|' +
    '([^\r\n]*?\r?\n((    |\t)at[^\r\n]*?\r?\n)*' +
    'Caused by: (?P<exception2>[\w]+)(?:\:\s)?(?P<message2>[^\r?]+))\r?)\n' +
    '(?P<trace>(\s+at .+\((((.*)(:(\d+))?)|(Native Method))\)\r?\n)+)(.*\r?\n)*' +
    '(Exception in thread "\w+" )?',
    re.MULTILINE
)
JAVA_STACK = re.compile(
    '^\s+at (?P<module>.+)\((((?P<file>.*?)(:(?P<line>\d+))?)|(Native Method))\)\r?\n',
    re.MULTILINE
)

PYDEV_CONNECT = re.compile("pydev debugger: process \\d+ is connecting[\r|\n]+")

PYTHON_EXCEPTION = re.compile(
    'Traceback \(most recent call last\):\r?\n(  File "(?P<file>.*)", line (?P<line>\d+), ' +
    'in .*\r?\n    .*\r?\n)+(?P<exception>[^:]*)(?::\s)?(?P<message>.*\n)$')

PYTHON_STACK = re.compile('  File "(?P<file>.*)", line (?P<line>\d+), in .*\r?\n    .*\r?\n')

MEMORY_REFERENCE = re.compile('0x[\dABCDEFabcdef]{4,16}')

END_OF_CODE_STRING = '===end of test==='
END_OF_CODE_STRING_NEWLINE = END_OF_CODE_STRING + '\n'

# Prevent floating point discrepancies in very low significant digits from being an issue
FLOAT_PRECISION = re.compile('(\\.\d{5})\d+')


def cleanse_java(raw, substitutions):
    matches = JAVA_EXCEPTION.search(raw)
    if matches is not None:
        groups = matches.groupdict()
        if groups['exception2'] is not None:
            # Test the specific message
            out = JAVA_EXCEPTION.sub(
                '### EXCEPTION ###{linesep}\\g<exception2>: \\g<message2>{linesep}\\g<trace>'.format(
                    linesep=os.linesep
                ),
                raw
            )
        else:
            # Test the specific message
            out = JAVA_EXCEPTION.sub(
                '### EXCEPTION ###{linesep}\\g<exception1>: \\g<message1>{linesep}\\g<trace>'.format(
                    linesep=os.linesep
                ),
                raw
            )
    else:
        out = raw

    stack = JAVA_STACK.findall(out)
    out = JAVA_STACK.sub('', out)

    out = '%s%s%s' % (
        out,
        os.linesep.join([
            "    %s:%s" % (s[3], s[5])
            for s in stack[::-1]
            if s[0].startswith('python.') and not s[0].endswith('.<init>') and s[5]
        ]),
        os.linesep if stack else ''
    )

    out = MEMORY_REFERENCE.sub("0xXXXXXXXX", out)

    out = out.replace(
        "'python.test'", '***EXECUTABLE***').replace(
        "'python.testdaemon.TestDaemon'", '***EXECUTABLE***')

    # Replace references to the test script with something generic
    out = out.replace("'test.py'", '***EXECUTABLE***')

    # Replace all the explicit data substitutions
    if substitutions:
        for to_value, from_values in substitutions.items():
            for from_value in from_values:
                out = out.replace(from_value, to_value)

    out = out.replace('\r\n', '\n')

    # Replace high precision floats with abbreviated forms
    out = FLOAT_PRECISION.sub('\\1...', out)

    return out


def cleanse_python(raw, substitutions):
    # Test the specific message
    out = PYTHON_EXCEPTION.sub(
        '### EXCEPTION ###{linesep}\\g<exception>: \\g<message>'.format(linesep=os.linesep),
        raw
    )

    out = PYDEV_CONNECT.sub('', out)

    stack = PYTHON_STACK.findall(raw)
    out = '%s%s%s' % (
        out,
        os.linesep.join(
            [
                "    %s:%s" % (s[0], s[1])
                for s in stack
            ]
        ),
        os.linesep if stack else ''
    )
    # Normalize memory references from output
    out = MEMORY_REFERENCE.sub("0xXXXXXXXX", out)

    # Replace references to the test script with something generic
    out = out.replace("'test.py'", '***EXECUTABLE***')

    if substitutions:
        for to_value, from_values in substitutions.items():
            for from_value in from_values:
                out = out.replace(from_value, to_value)

    out = out.replace('\r\n', '\n')

    # Replace high precision floats with abbreviated forms
    out = FLOAT_PRECISION.sub('\\1...', out)

    return out


class TranspileTestCase(TestCase):

    @classmethod
    def setUpClass(cls):
        global _output_dir
        setUpSuite()
        cls.temp_dir = os.path.join(_output_dir, 'temp')
        classpath = os.pathsep.join([
            os.path.abspath(os.path.join('dist', 'python-java-testdaemon.jar')),
            os.path.abspath(os.path.join('dist', 'python-java-support.jar')),
        ])
        cls.jvm = subprocess.Popen(
            ["java", "-classpath", classpath, "python.testdaemon.TestDaemon"],
            stdin=subprocess.PIPE,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            cwd=_output_dir,
        )

    @classmethod
    def tearDownClass(cls):
        if cls.jvm is not None:
            # use communicate here to wait for process to exit
            cls.jvm.communicate("exit".encode("utf-8"))

    def assertBlock(self, python, java):
        self.maxDiff = None
        dump = False

        py_block = PyBlock(parent=PyModule('test', 'test.py'))
        if python:
            python = adjust(python)
            code = compile(python, '<test>', 'exec')
            py_block.extract(code, debug=dump)

        java_code = py_block.transpile()

        out = BytesIO()
        constant_pool = ConstantPool()
        java_code.resolve(constant_pool)

        constant_pool.add(Utf8('test'))
        constant_pool.add(Utf8('Code'))
        constant_pool.add(Utf8('LineNumberTable'))

        writer = ClassFileWriter(out, constant_pool)
        java_code.write(writer)

        debug = StringIO()
        reader = ClassFileReader(BytesIO(out.getbuffer()), constant_pool, debug=debug)
        JavaCode.read(reader, dump=0)

        if dump:
            print(debug.getvalue())

        java = adjust(java)
        self.assertEqual(debug.getvalue(), java[1:])

    def assertCodeExecution(
            self, code,
            message=None,
            extra_code=None,
            run_in_global=True, run_in_function=True, exits_early=False,
            args=None, substitutions=None):
        """"Run code as native python, and under Java and check the output is identical"""
        self.maxDiff = None
        # ==================================================
        # Pass 1 - run the code in the global context
        # ==================================================
        if run_in_global:
            try:
                self.makeTempDir()
                # Run the code as Python and as Java.
                adj_code = adjust(code, run_in_function=False)
                adj_code += '\nprint("%s")\n' % END_OF_CODE_STRING
                py_out = runAsPython(self.temp_dir, adj_code, extra_code, args=args)
                java_out = self.runAsJava(adj_code, extra_code, args=args)
            except Exception as e:
                self.fail(e)
            finally:
                # Clean up the test directory where the class file was written.
                # (we have to set ignore_errors for this to work on Windows)
                # Ignoring all errors can be anxiety-inducing so we'll do one more
                # check on the directory afterwards now.
                shutil.rmtree(self.temp_dir, ignore_errors=True)

                if os.path.exists(self.temp_dir):
                    raise IsADirectoryError("{} was unsuccessfully deleted".format
                                            (self.temp_dir))
                # print(java_out)

            # Cleanse the Python and Java output, producing a simple
            # normalized format for exceptions, floats etc.
            java_out = cleanse_java(java_out, substitutions)
            py_out = cleanse_python(py_out, substitutions)

            # Confirm that the output of the Java code is the same as the Python code.
            if message:
                context = 'Global context: %s' % message
            else:
                context = 'Global context'
            self.assertEqual(java_out, py_out, context)

            # Confirm that both output strings end with the canary statement
            if exits_early:
                if java_out.endswith(END_OF_CODE_STRING_NEWLINE):
                    self.fail("Java test failed to raise exception \n%s" % java_out)
                if py_out.endswith(END_OF_CODE_STRING_NEWLINE):
                    self.fail("Python test failed to raise exception \n%s" % py_out)
            else:
                if not java_out.endswith(END_OF_CODE_STRING_NEWLINE):
                    self.fail("Java test failed prematurely \n%s" % java_out)
                if not py_out.endswith(END_OF_CODE_STRING_NEWLINE):
                    self.fail("Python test failed prematurely \n%s" % py_out)
        # ==================================================
        # Pass 2 - run the code in a function's context
        # ==================================================
        if run_in_function:
            try:
                self.makeTempDir()
                # Run the code as Python and as Java.
                adj_code = adjust(code, run_in_function=True)
                adj_code += '\nprint("%s")\n' % END_OF_CODE_STRING
                py_out = runAsPython(self.temp_dir, adj_code, extra_code, args=args)
                java_out = self.runAsJava(adj_code, extra_code, args=args)
            except Exception as e:
                self.fail(e)
            finally:
                # Clean up the test directory where the class file was written.
                shutil.rmtree(self.temp_dir)
                # print(java_out)

            # Cleanse the Python and Java output, producing a simple
            # normalized format for exceptions, floats etc.
            java_out = cleanse_java(java_out, substitutions)
            py_out = cleanse_python(py_out, substitutions)

            # Confirm that the output of the Java code is the same as the Python code.
            if message:
                context = 'Function context: %s' % message
            else:
                context = 'Function context'
            self.assertEqual(java_out, py_out, context)

            # Confirm that both output strings end with the canary statement
            if exits_early:
                if java_out.endswith(END_OF_CODE_STRING_NEWLINE):
                    self.fail("Java test failed to raise exception \n%s" % java_out)
                if py_out.endswith(END_OF_CODE_STRING_NEWLINE):
                    self.fail("Python test failed to raise exception \n%s" % py_out)
            else:
                if not java_out.endswith(END_OF_CODE_STRING_NEWLINE):
                    self.fail("Java test failed prematurely \n%s" % java_out)
                if not py_out.endswith(END_OF_CODE_STRING_NEWLINE):
                    self.fail("Python test failed prematurely \n%s" % py_out)

    def assertJavaExecution(
                self, code, out,
                extra_code=None, java=None,
                run_in_global=True, run_in_function=True,
                args=None, substitutions=None):
        """Run code under Java and check the output is as expected"""
        global _output_dir
        self.maxDiff = None
        try:
            # ==================================================
            # Prep - compile any required Java sources
            # ==================================================
            # Create the temp directory into which code will be placed
            java_dir = os.path.join(_output_dir, 'java')

            try:
                os.makedirs(java_dir)
            except FileExistsError:
                pass

            # Compile the java support code
            java_compile_out = compileJava(java_dir, java)

            if java_compile_out:
                self.fail(java_compile_out)

            # Cleanse the Python output, producing a simple
            # normalized format for exceptions, floats etc.
            py_out = adjust(out)

            # ==================================================
            # Pass 1 - run the code in the global context
            # ==================================================
            if run_in_global:
                try:
                    self.makeTempDir()
                    # Run the code as Java.
                    java_out = self.runAsJava(adjust(code), extra_code, args=args)
                except Exception as e:
                    self.fail(e)
                finally:
                    # Clean up the test directory where the class file was written.
                    shutil.rmtree(self.temp_dir)
                    # print(java_out)

                # Cleanse the Java output, producing a simple
                # normalized format for exceptions, floats etc.
                java_out = cleanse_java(java_out, substitutions)

                # Confirm that the output of the Java code is the same as the Python code.
                self.assertEqual(java_out, py_out, 'Global context')

            # ==================================================
            # Pass 2 - run the code in a function's context
            # ==================================================
            if run_in_function:
                try:
                    self.makeTempDir()
                    # Run the code as Java.
                    java_out = self.runAsJava(
                        adjust(code, run_in_function=True),
                        extra_code, args=args)
                except Exception as e:
                    self.fail(e)
                finally:
                    # Clean up the test directory where the class file was written.
                    shutil.rmtree(self.temp_dir)
                    # print(java_out)

                # Cleanse the Java output, producing a simple
                # normalized format for exceptions, floats etc.
                java_out = cleanse_java(java_out, substitutions)

                # Confirm that the output of the Java code is the same as the Python code.
                self.assertEqual(java_out, py_out, 'Function context')

        finally:
            # Clean up the java directory where the class file was written.
            if os.path.exists(java_dir):
                shutil.rmtree(java_dir)

    def makeTempDir(self):
        """Create a "temp" subdirectory in the class's generated temporary directory if it doesn't currently exist."""
        try:
            os.mkdir(self.temp_dir)
        except FileExistsError:
            pass

    def runAsJava(self, main_code, extra_code=None, args=None, timed=False):
        """Run a block of Python code as a Java program."""
        # Output source code into test directory
        transpiler = Transpiler(verbosity=0)

        # Don't redirect stderr; we want to see any errors from the transpiler
        # as top level test failures.
        with capture_output(redirect_stderr=False):

            transpiler.transpile_string("test.py", main_code)

            if extra_code:
                for name, code in extra_code.items():
                    transpiler.transpile_string("%s.py" % name.replace('.', os.path.sep), adjust(code))

        transpiler.write(self.temp_dir)

        if args is None:
            args = []

        t1_start = time.perf_counter()
        t2_start = time.process_time()

        if len(args) == 0:

            # encode to turn str into bytes-like object
            self.jvm.stdin.write(("python.test\n").encode("utf-8"))
            self.jvm.stdin.flush()

            out = ""
            while True:
                try:
                    line = self.jvm.stdout.readline().decode("utf-8")
                    if line == ".{0}".format(os.linesep):
                        break
                    else:
                        out += line
                except IOError as e:
                    continue

            t1_stop = time.perf_counter()
            t2_stop = time.process_time()

        else:
            classpath = os.pathsep.join([
                os.path.abspath(os.path.join('dist', 'python-java-support.jar')),
                os.path.abspath(os.path.join('java')),
                os.path.abspath(self.temp_dir),
            ])

            proc = subprocess.Popen(
                ["java", "-classpath", classpath, "python.test"] + args,
                stdin=subprocess.PIPE,
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                cwd=self.temp_dir
            )

            t1_stop = time.perf_counter()
            t2_stop = time.process_time()

            out = proc.communicate()[0].decode('utf8')

            if proc.returncode != 0:
                raise Exception(
                    "Java subprocess didn't exit cleanly (exit status %s)\n\n: %s" % (
                        proc.returncode, out
                    )
                )

        if timed:
            print("  Elapsed time: ", (t1_stop-t1_start), " sec")
            print("  CPU process time: ", (t2_stop-t2_start), " sec")

        return out


class NotImplementedToExpectedFailure:
    def _is_flakey(self):
        return self._testMethodName in getattr(self, "is_flakey", [])

    def _is_not_implemented(self):
        '''
        A test is expected to fail if:
          (a) Its name can be found in the test case's 'not_implemented' list
          (b) Its name can be found in the test case's 'is_flakey' list
          (c) Its name can be found in the test case's
              'not_implemented_versions' dictionary _and_ the current
              python version is in the dict entry's list
        :return: True if test is expected to fail
        '''
        method_name = self._testMethodName
        if method_name in getattr(self, 'not_implemented', []):
            return True

        if self._is_flakey():
            # -- Flakey tests sometimes fail, sometimes pass
            return True

        not_implemented_versions = getattr(self, 'not_implemented_versions', {})
        if method_name in not_implemented_versions:
            py_version = float("%s.%s" % (sys.version_info.major, sys.version_info.minor))
            if py_version in not_implemented_versions[method_name]:
                return True

        return False

    def run(self, result=None):
        # Override the run method to inject the "expectingFailure" marker
        # when the test case runs.
        if self._is_not_implemented():
            # Mark 'expecting failure' on class. It will only be applicable
            # for this specific run.

            # -- Save the original test_method _before_ we
            # overwrite it with wrapper
            test_method = getattr(self, self._testMethodName)

            def wrapper(*args, **kwargs):
                if self._is_flakey():
                    raise Exception("Flakey test that sometimes fails and sometimes passes")
                return test_method(*args, **kwargs)

            wrapper.__unittest_expecting_failure__ = True
            setattr(self, self._testMethodName, wrapper)
        return super().run(result=result)


SAMPLE_DATA = {
    'obj': [
            'object',
            'object()',
        ],
    'bool': [
            'True',
            'False',
        ],
    'bytearray': [
            'bytearray()',
            'bytearray(1)',
            'bytearray([1, 2, 3])',
            # 'bytearray(b"hello world")',
        ],
    'bytes': [
            'b""',
            'b"This is another string of bytes"',
            'b"One arg: %s"',
            'b"Three args: %s | %s | %s"',
        ],
    'class': [
            'type(1)',
            'type("a")',
            'type(object())',
            'type("MyClass", (object,), {})',
            # This datatype is here and must be here in order to "clear" the state set by type("MyClass", (object,), {})
            'type("object", (object,), {})'
        ],
    'complex': [
            '1j',
            '3.14159265j',
            '1+2j',
            '3-4j',
            '-5j',
        ],
    'dict': [
            '{}',
            '{"a": 1, "c": 2.3456, "d": "another"}',
        ],
    'float': [
            '2.3456',
            '0.0',
            '-3.14159',
            '-4.81756',
            '5.5',
            '-3.5',
            '4.5',
            '-4.5',
        ],
    'frozenset': [
            'frozenset()',
            'frozenset({"on","to","an"})',
            'frozenset({"one","two","six"})',
            'frozenset({1, 2.3456, 7})',
        ],
    'int': [
            '0',
            '-5',
            '-3',
            '3',
            '1',
        ],
    'list': [
            '[]',
            '[3, 4, 5]',
            '[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]',
            '["a","b","c"]',
        ],
    'range': [
            'range(0)',
            'range(5)',
            'range(2, 7)',
            'range(2, 7, 2)',
            'range(7, 2, -1)',
            'range(7, 2, -2)',
        ],
    'set': [
            'set()',
            'set({"on","to","an"})',
            'set({"one","two","six"})',
            'set({1, 2.3456, 7})',
        ],
    'slice': [
            'slice(0)',
            'slice(5)',
            'slice(2, 7)',
            'slice(2, 7, 2)',
            'slice(7, 2, -1)',
            'slice(7, 2, -2)',
        ],
    'str': [
            '""',
            '"3"',
            '"This is another string"',
            '"Mÿ hôvèrçràft îß fûłl öf éêlś"',
            '"One arg: %s"',
            '"Three args: %s | %s | %s"',
        ],
    'tuple': [
            '(1,)',
            '(1, 2)',
            '(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)',
            '(3, 1.2, True, )',
            '(1, 2.3456, "another")',
        ],
    'None': [
            'None',
        ],
    'NotImplemented': [
            'NotImplemented',
        ],
}


def _string_substitutions(string):
    delims = (string[0], string[-1])
    string = string[1:-1]
    elems = [elem for elem in string.split(", ")]
    perms = permutations(elems)
    return [(delims[0] + ", ".join(list(perm)) + delims[1]) for perm in perms]


SAMPLE_SUBSTITUTIONS = {
    # Normalize set ordering
    "{1, 2.3456, 7}": _string_substitutions("{1, 2.3456, 7}"),
    "{1, 2.3456, 'another'}": _string_substitutions("{1, 2.3456, 'another'}"),
    "{'an', 'to', 'on'}": _string_substitutions("{'an', 'to', 'on'}"),
    "{'one', 'two', 'six'}": _string_substitutions("{'one', 'two', 'six'}"),
    "{'a', 'b', 'c'}": _string_substitutions("{'a', 'b', 'c'}"),


    # Normalize list ordering
    "[1, 2.3456, 7]": _string_substitutions("[1, 2.3456, 7]"),
    "[1, 2.3456, 'another']": _string_substitutions("[1, 2.3456, 'another']"),
    "['a', 'b', 'c']": _string_substitutions("['a', 'b', 'c']"),
    "['an', 'to', 'on']": _string_substitutions("['an', 'to', 'on']"),

    "['one', 'two', 'six']": _string_substitutions("['one', 'two', 'six']"),
    # Normalize dictionary ordering
    "{'a': 1, 'c': 2.3456, 'd': 7}": _string_substitutions(
                                              "{'a': 1, 'c': 2.3456, 'd': 7}"),
    "{'a': 1, 'c': 2.3456, 'd': 'another'}": _string_substitutions(
                                      "{'a': 1, 'c': 2.3456, 'd': 'another'}"),
    "{'a': 'n', 't': 'o', 'o': 'n'}": _string_substitutions(
                                             "{'a': 'n', 't': 'o', 'o': 'n'}"),
    "{'to', 'two', 'one', 'an', 'on', 'six'}": _string_substitutions(
                                    "{'to', 'two', 'one', 'an', 'on', 'six'}"),
    "{1, 2.3456, 7, 'one', 'six', 'two'}": _string_substitutions(
                                        "{1, 2.3456, 7, 'one', 'six', 'two'}"),
    "{1, 2.3456, 7, 'on', 'an', 'to'}": _string_substitutions(
                                        "{1, 2.3456, 7, 'on', 'an', 'to'}"),
    "{1, 2.3456, 7, 1, 2.3456, 7}": _string_substitutions(
                                        "{1, 2.3456, 7, 1, 2.3456, 7}"),
    # Normalize precision error
    "-3.14159": ["-3.1415900000000008"],
}


def _unary_test(test_name, operation):
    def func(self):
        self.assertUnaryOperation(
            x_values=SAMPLE_DATA[self.data_type],
            operation=operation,
            format=self.format,
            substitutions=getattr(self, 'substitutions', SAMPLE_SUBSTITUTIONS)
        )
    return func


class UnaryOperationTestCase(NotImplementedToExpectedFailure):
    format = ''

    def assertUnaryOperation(self, x_values, operation, format, substitutions):
        self.assertCodeExecution(
            '##################################################\n'.join(
                adjust("""
                    try:
                        print('>>> x = %(x)s')
                        print('>>> %(format)s%(operation)sx')
                        x = %(x)s
                        print(%(format)s%(operation)sx)
                    except Exception as e:
                        print(type(e), ':', e)
                    print()
                    """ % {
                        'x': x,
                        'operation': operation,
                        'format': format,
                    }
                )
                for x in x_values
            ),
            "Error running %s" % operation,
            substitutions=substitutions,
            run_in_function=False,
        )

    test_unary_positive = _unary_test('test_unary_positive', '+')
    test_unary_negative = _unary_test('test_unary_negative', '-')
    test_unary_not = _unary_test('test_unary_not', 'not ')
    test_unary_invert = _unary_test('test_unary_invert', '~')


def _binary_test(test_name, operation, examples):
    def func(self):
        self.assertBinaryOperation(
            x_values=SAMPLE_DATA[self.data_type],
            y_values=examples,
            operation=operation,
            format=self.format,
            substitutions=getattr(self, 'substitutions', SAMPLE_SUBSTITUTIONS)
        )
    return func


class BinaryOperationTestCase(NotImplementedToExpectedFailure):
    format = ''
    y = 3

    def assertBinaryOperation(self, x_values, y_values, operation, format, substitutions):
        data = []
        for x in x_values:
            for y in y_values:
                data.append((x, y))

        self.assertCodeExecution(
            '##################################################\n'.join(
                adjust("""
                    try:
                        print('>>> x = %(x)s')
                        print('>>> y = %(y)s')
                        print('>>> %(format)s%(operation)s')
                        x = %(x)s
                        y = %(y)s
                        print(%(format)s%(operation)s)
                    except Exception as e:
                        print(type(e), ':', e)
                    print()
                    """ % {
                        'x': x,
                        'y': y,
                        'operation': operation,
                        'format': format,
                    }
                )
                for x, y in data
            ),
            "Error running %s" % operation,
            substitutions=substitutions,
            run_in_function=False,
        )

    for datatype, examples in SAMPLE_DATA.items():
        vars()['test_add_%s' % datatype] = _binary_test('test_add_%s' % datatype, 'x + y', examples)
        vars()['test_subtract_%s' % datatype] = _binary_test('test_subtract_%s' % datatype, 'x - y', examples)
        vars()['test_multiply_%s' % datatype] = _binary_test('test_multiply_%s' % datatype, 'x * y', examples)
        vars()['test_floor_divide_%s' % datatype] = _binary_test('test_floor_divide_%s' % datatype, 'x // y', examples)
        vars()['test_true_divide_%s' % datatype] = _binary_test('test_true_divide_%s' % datatype, 'x / y', examples)
        vars()['test_modulo_%s' % datatype] = _binary_test('test_modulo_%s' % datatype, 'x % y', examples)
        vars()['test_power_%s' % datatype] = _binary_test('test_power_%s' % datatype, 'x ** y', examples)
        vars()['test_subscr_%s' % datatype] = _binary_test('test_subscr_%s' % datatype, 'x[y]', examples)
        vars()['test_lshift_%s' % datatype] = _binary_test('test_lshift_%s' % datatype, 'x << y', examples)
        vars()['test_rshift_%s' % datatype] = _binary_test('test_rshift_%s' % datatype, 'x >> y', examples)
        vars()['test_and_%s' % datatype] = _binary_test('test_and_%s' % datatype, 'x & y', examples)
        vars()['test_xor_%s' % datatype] = _binary_test('test_xor_%s' % datatype, 'x ^ y', examples)
        vars()['test_or_%s' % datatype] = _binary_test('test_or_%s' % datatype, 'x | y', examples)

        vars()['test_lt_%s' % datatype] = _binary_test('test_lt_%s' % datatype, 'x < y', examples)
        vars()['test_le_%s' % datatype] = _binary_test('test_le_%s' % datatype, 'x <= y', examples)
        vars()['test_gt_%s' % datatype] = _binary_test('test_gt_%s' % datatype, 'x > y', examples)
        vars()['test_ge_%s' % datatype] = _binary_test('test_ge_%s' % datatype, 'x >= y', examples)
        vars()['test_eq_%s' % datatype] = _binary_test('test_eq_%s' % datatype, 'x == y', examples)
        vars()['test_ne_%s' % datatype] = _binary_test('test_ne_%s' % datatype, 'x != y', examples)

        vars()['test_direct_lt_%s' % datatype] = _binary_test('test_lt_%s' % datatype, 'x.__lt__(y)', examples)
        vars()['test_direct_le_%s' % datatype] = _binary_test('test_le_%s' % datatype, 'x.__le__(y)', examples)
        vars()['test_direct_gt_%s' % datatype] = _binary_test('test_gt_%s' % datatype, 'x.__gt__(y)', examples)
        vars()['test_direct_ge_%s' % datatype] = _binary_test('test_ge_%s' % datatype, 'x.__ge__(y)', examples)
        vars()['test_direct_eq_%s' % datatype] = _binary_test('test_eq_%s' % datatype, 'x.__eq__(y)', examples)
        vars()['test_direct_ne_%s' % datatype] = _binary_test('test_ne_%s' % datatype, 'x.__ne__(y)', examples)


def _inplace_test(test_name, operation, examples):
    def func(self):
        self.assertInplaceOperation(
            x_values=SAMPLE_DATA[self.data_type],
            y_values=examples,
            operation=operation,
            format=self.format,
            substitutions=getattr(self, 'substitutions', SAMPLE_SUBSTITUTIONS)
        )
    return func


class InplaceOperationTestCase(NotImplementedToExpectedFailure):
    format = ''
    y = 3

    def assertInplaceOperation(self, x_values, y_values, operation, format, substitutions):
        data = []
        for x in x_values:
            for y in y_values:
                data.append((x, y))

        self.assertCodeExecution(
            '##################################################\n'.join(
                adjust("""
                    try:
                        print('>>> x = %(x)s')
                        print('>>> y = %(y)s')
                        print('>>> %(operation)s')
                        print('>>> %(format)sx')
                        x = %(x)s
                        y = %(y)s
                        %(operation)s
                        print(%(format)sx)
                    except Exception as e:
                        print(type(e), ':', e)
                    print()
                    """ % {
                        'x': x,
                        'y': y,
                        'operation': operation,
                        'format': format,
                    }
                )
                for x, y in data
            ),
            "Error running %s" % operation,
            substitutions=substitutions,
            run_in_function=False,
        )

    for datatype, examples in SAMPLE_DATA.items():
        vars()['test_add_%s' % datatype] = _inplace_test(
            'test_add_%s' % datatype,
            'x += y',
            examples
        )
        vars()['test_subtract_%s' % datatype] = _inplace_test(
            'test_subtract_%s' % datatype,
            'x -= y',
            examples
        )
        vars()['test_multiply_%s' % datatype] = _inplace_test(
            'test_multiply_%s' % datatype,
            'x *= y',
            examples
        )
        vars()['test_floor_divide_%s' % datatype] = _inplace_test(
            'test_floor_divide_%s' % datatype,
            'x //= y',
            examples
        )
        vars()['test_true_divide_%s' % datatype] = _inplace_test(
            'test_true_divide_%s' % datatype,
            'x /= y',
            examples
        )
        vars()['test_modulo_%s' % datatype] = _inplace_test(
            'test_modulo_%s' % datatype,
            'x %= y',
            examples
        )
        vars()['test_power_%s' % datatype] = _inplace_test(
            'test_power_%s' % datatype,
            'x **= y',
            examples
        )
        vars()['test_lshift_%s' % datatype] = _inplace_test(
            'test_lshift_%s' % datatype,
            'x <<= y',
            examples
        )
        vars()['test_rshift_%s' % datatype] = _inplace_test(
            'test_rshift_%s' % datatype,
            'x >>= y',
            examples
        )
        vars()['test_and_%s' % datatype] = _inplace_test(
            'test_and_%s' % datatype,
            'x &= y',
            examples
        )
        vars()['test_xor_%s' % datatype] = _inplace_test(
            'test_xor_%s' % datatype,
            'x ^= y',
            examples
        )
        vars()['test_or_%s' % datatype] = _inplace_test(
            'test_or_%s' % datatype,
            'x |= y',
            examples
        )


def _builtin_test(test_name, operation, examples):
    def func(self):
        self.assertBuiltinFunction(
            x_values=examples,
            f_values=self.functions,
            operation=operation,
            format=self.format,
            substitutions=getattr(self, 'substitutions', SAMPLE_SUBSTITUTIONS)
        )
    return func


class BuiltinFunctionTestCase(NotImplementedToExpectedFailure):
    format = ''

    def assertBuiltinFunction(self, f_values, x_values, operation, format, substitutions):
        data = []
        for f in f_values:
            for x in x_values:
                data.append((f, x))

        self.assertCodeExecution(
            '##################################################\n'.join(
                adjust("""
                    try:
                        print('>>> f = %(f)s')
                        print('>>> x = %(x)s')
                        print('>>> %(format)s%(operation)s')
                        f = %(f)s
                        x = %(x)s
                        print(%(format)s%(operation)s)
                    except Exception as e:
                        print(type(e), ':', e)
                    print()
                    """ % {
                        'f': f,
                        'x': x,
                        'operation': operation,
                        'format': format,
                    }
                )
                for f, x in data
            ),
            "Error running %s" % operation,
            substitutions=substitutions,
            run_in_function=False,
        )

    for datatype, examples in SAMPLE_DATA.items():
        vars()['test_%s' % datatype] = _builtin_test('test_%s' % datatype, 'f(x)', examples)


def _builtin_twoarg_test(test_name, operation, examples1, examples2):
    def func(self):
        self.assertBuiltinTwoargFunction(
            f_values=self.functions,
            x_values=examples1,
            y_values=examples2,
            operation=operation,
            format=self.format,
            substitutions=getattr(self, 'substitutions', SAMPLE_SUBSTITUTIONS)
        )
    return func


class BuiltinTwoargFunctionTestCase(NotImplementedToExpectedFailure):
    format = ''

    def assertBuiltinTwoargFunction(self, f_values, x_values, y_values, operation, format, substitutions):
        data = []
        for f in f_values:
            for x in x_values:
                for y in y_values:
                    data.append((f, x, y))

        self.assertCodeExecution(
            '##################################################\n'.join(
                adjust("""
                    try:
                        print('>>> f = %(f)s')
                        print('>>> x = %(x)s')
                        print('>>> y = %(y)s')
                        print('>>> %(format)s%(operation)s')
                        f = %(f)s
                        x = %(x)s
                        y = %(y)s
                        print(%(format)s%(operation)s)
                    except Exception as e:
                        print(type(e), ':', e)
                    print()
                    """ % {
                        'f': f,
                        'x': x,
                        'y': y,
                        'operation': operation,
                        'format': format,
                    }
                )
                for f, x, y in data
            ),
            "Error running %s" % operation,
            substitutions=substitutions,
            run_in_function=False,
        )

    for datatype1, examples1 in SAMPLE_DATA.items():
        for datatype2, examples2 in SAMPLE_DATA.items():
            vars()['test_%s_%s' % (datatype1, datatype2)] = _builtin_twoarg_test(
                'test_%s_%s' % (datatype1, datatype2),
                'f(x, y)', examples1, examples2
            )
